Skip to content

Resolve current stack of stuck lock holders - #1701

Open
erikhortsch wants to merge 8 commits into
mainfrom
erik/lock-holder-stack
Open

Resolve current stack of stuck lock holders#1701
erikhortsch wants to merge 8 commits into
mainfrom
erik/lock-holder-stack

Conversation

@erikhortsch

@erikhortsch erikhortsch commented Aug 5, 2026

Copy link
Copy Markdown

Motivation

During a recent incident, the lock tracker's stuck-lock logs told us where goroutines were waiting, but not where the holder was blocked. The tracker records the acquisition stack (FirstLockedAtStack), which points at where the lock was grabbed — not at the call the holder is currently stuck in. And since holder goroutine ids aren't recorded, their stacks can't be picked out of a goroutine dump either: waiters are self-evident (parked in sync.Mutex.Lock), holders are anonymous.

Changes

Single-holder tracking for exclusive locks (lock_tracker.go): Mutex has exactly one holder and trackLock/trackUnlock run while it's held, so the holder gid is a plain field — no atomics except the waiter count, and a cross-goroutine unlock simply clears it. Lock paths guard tracking behind one nil check; unlock paths use a bare atomic load (loadTracker) instead of the lazy-init.

Read-holder tracking as an RW extension (lock_tracker_rw.go): RWMutex write holds use the embedded single-holder path; concurrent readers use atomic gid slots (up to 8, overflow-counted). Reader accounting removes exactly one entry per RUnlock — own gid, else overflow credit, else arbitrary eviction (the same cross-goroutine-unlock policy go-deadlock uses; compare HolderGoroutineIDs with NumGoroutineHeld to detect overflow).

Holder stacks: PopulateHolderStacks(stuck) resolves each holder's current stack from a single runtime.Stack(buf, true) snapshot. The snapshot stops the world, so consumers run it once per detection episode rather than on every scan.

StuckLock API: HolderGoroutineIDs(), HolderStacks(), and HolderStrength() (exclusive vs shared readers).

Cost (Apple M5 Pro, per Lock/Unlock pair)

before after
wrapped Mutex 6.2ns 6.3ns
wrapped RWMutex (write) ~6.5ns 8.3ns
native sync.Mutex 4.0ns 4.0ns

Holder tracking on exclusive locks is effectively free; the RW read path pays goid+CAS each way.

Testing

Holder-stack resolution, cross-goroutine unlock, reader overflow, and holder-strength tests; full ./utils suite passes with -race.

🤖 Generated with Claude Code

The lock tracker records where a stuck lock was first acquired, but not
what its holder is doing now — during an incident the waiters are easy
to find in a goroutine dump while the blocked holder is anonymous.
Record the holder's goroutine id on acquisition (~0.3ns via goid) and
add PopulateHolderStacks to resolve holders' current stacks from a
single runtime.Stack snapshot at scan time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1414a20

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
github.com/livekit/protocol Patch
@livekit/protocol Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@paulwe paulwe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this meaningfully impact overhead?

Comment thread utils/lock_tracker.go Outdated
Comment on lines 255 to 258

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can get rid of this?

Comment thread utils/lock_tracker.go Outdated

func (t *lockTracker) trackUnlock() {
if t != nil {
gid := goid.Get()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this guaranteed to be the same g that acquired the lock? this + the max 16 holders for rw locks seems a bit sketchy

@boks1971 boks1971 Aug 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the comment above in trackLock, guessing it is one of the goroutines that is holding the lock. So, it should ideally be in one of these slots or an overflow. Wonder what is the case where the overflow removal also fails and a random one is removed from the slots.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible that a lock is acquired from 1 goroutine and then released from another - that feels like a hacky pattern that should probably be avoided. Maybe something that we specifically log on in staging

Track every holder gid (up to 8 concurrent RWMutex readers) in atomic
slots instead of only the first, so PopulateHolderStacks resolves all
holders' current stacks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikhortsch
erikhortsch force-pushed the erik/lock-holder-stack branch from a8664f3 to c53ceae Compare August 5, 2026 03:46
Every unlock now removes exactly one holder entry: its own gid if
present, else an overflow credit, else an arbitrary slot (same policy
go-deadlock uses for cross-goroutine unlocks). Replaces the racy
wipe-all-slots-at-zero, making occupied slots + overflow == holders an
exact invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@boks1971 boks1971 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will be very useful. Thank you @erikhortsch

Comment thread utils/lock_tracker.go Outdated

func (t *lockTracker) trackUnlock() {
if t != nil {
gid := goid.Get()

@boks1971 boks1971 Aug 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the comment above in trackLock, guessing it is one of the goroutines that is holding the lock. So, it should ideally be in one of these slots or an overflow. Wonder what is the case where the overflow removal also fails and a random one is removed from the slots.

erikhortsch and others added 4 commits August 5, 2026 13:06
Mutex has exactly one holder and trackLock/trackUnlock run while it is
held, so its bookkeeping needs no atomics — a plain gid store replaces
the slot claim, and cross-goroutine unlock just clears it. The slot
array, overflow accounting, and eviction heuristics now exist only for
RWMutex readers in lock_tracker_rw.go, where holders are genuinely
concurrent. Wrapped Mutex Lock/Unlock: 8.7ns -> 6.1ns (pre-tracking
baseline 6.2ns, native 4.0ns).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lock paths guard tracking behind one nil check with the post-acquire
bookkeeping deferred; unlock paths use a bare atomic load (loadTracker)
instead of the full lazy-init, since a lock being unlocked was locked
first. StuckLock gains HolderStrength (exclusive vs shared readers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lazyInitTracker now CASes the constructed tracker directly: construct
is pure allocation, the CAS winner registers, losers are left to the
GC. This removes the mid-init sentinel state, the runtime spin
linknames, and the per-type registration/finalizer duplication. Both
tracker types live in one registry — rwLockTracker extends lockTracker,
so the scan reads the base through every ref and toStuckLock gathers
reader slots when the rw flag is set. window is a weakRefList method
with the cursor as a field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants